module MolFileValidator

  def self.included(base)
    base.extend(ClassMethods)
  end

  module ClassMethods
    def validates_mol_file_structure(*args)
      validate :mol_file_structure, *args
    end
  end

  def mol_file_structure
    # Don't raise errors if the molfile is empty or nil.
    return if mol_file.blank?

    regex_version = /V(?:2000|3000)/
    regex_coordinate = /-?(?:\d){1,5}.(?:\d){1,4}/
    regex_symbol = /[A-Za-z]{1,2}/
    regex_properties = /^M\s+[A-Z]{3}.*$/
    regex_properties_end = /^M\s+END/

    lines = mol_file.split(/\r?\n|\r/) # Split mol_file into individual lines

    if lines[3].nil? then
      errors.add(:mol_file, 'contains unexpected EOF')
      return
    end

    # Verify structure of counts line.
    lines[3].each do |counts_line|
      errors.add(:mol_file, "contains invalid number of atoms in counts line: #{counts_line}") unless counts_line[0..2].to_i > 0 || counts_line[0..2].to_i <= 255
      errors.add(:mol_file, "contains invalid number of bonds in counts line: #{counts_line}") unless counts_line[3..5].to_i > 0 || counts_line[3..5].to_i <= 255
      errors.add(:mol_file, "contains invalid number of atom lists in counts line: #{counts_line}") unless counts_line[6..8].to_i > 0 || counts_line[6..8].to_i <= 30
      # Positions 9 - 11 obsolete.
      errors.add(:mol_file, "contains out of range chiral flag in counts line: #{counts_line}") unless (0..1).include? counts_line[12..14].to_i
      # Positions 15 - 32 obsolete.
      errors.add(:mol_file, "contains unknown molfile version string in counts line: #{counts_line}") unless counts_line[33..38] =~ regex_version
    end

    # Extract position of atom and bonds line from counts line.
    number_of_atoms = lines[3][0..2].to_i
    number_of_bonds = lines[3][3..5].to_i
    atom_lines = (4 .. 4 + number_of_atoms - 1)
    bond_lines = (4 + number_of_atoms .. 4 + number_of_atoms + number_of_bonds - 1)
    properties_lines = (4 + number_of_atoms + number_of_bonds + 1..lines.length)

    # Verify structure of atom block.
    lines[atom_lines].each do |atom_line|
      errors.add(:mol_file, "contains invalid coordinate in atom line: #{atom_line}") unless regex_coordinate.match(atom_line[0..9]) && regex_coordinate.match(atom_line[10..18]) && regex_coordinate.match(atom_line[19..28])
      errors.add(:mol_file, "contains unxpected symbol, expected space, found #{atom_line[30]} in atom line: #{atom_line}") unless atom_line[30..30] =~ /\s/
      errors.add(:mol_file, "contains invalid atom symbol in atom line: #{atom_line}") unless regex_symbol.match(atom_line[31..33])
      errors.add(:mol_file, "contains invalid mass difference in atom line: #{atom_line}") unless (-3..4).include? atom_line[34..35].to_i
      errors.add(:mol_file, "contains invalid charge in atom line: #{atom_line}") unless (0..7).include? atom_line[36..38].to_i
      errors.add(:mol_file, "contains invalid atom stereo parity in atom line: #{atom_line}") unless (0..3).include? atom_line[39..41].to_i

      # Note: molfile specification defines field as hydrogen count + 1
      # disallowing value 0 but molfiles in kegg/ligand database contain atom
      # lines with field set to 0.
      errors.add(:mol_file, "contains invalid hydrogen count in atom line: #{atom_line}") unless (0..5).include? atom_line[42..44].to_i
      errors.add(:mol_file, "contains invalid stereo care box in atom line: #{atom_line}") unless (0..1).include? atom_line[45..47].to_i
      errors.add(:mol_file, "contains invalid valence in atom line: #{atom_line}") unless (0..15).include? atom_line[47..49].to_i

      # Note: molfile specification defines field as atom-atom mapping number
      # with value range of 1 - number of atoms but molfiles in kegg/ligand
      # database contain atom lines with field set to 0.
      # Positions 50 - 59 unused or obsolete.
      errors.add(:mol_file, "contains invalid atom-atom mapping number in atom line: #{atom_line}") unless (0..number_of_atoms).include? atom_line[60..62].to_i
      errors.add(:mol_file, "contains invalid inversion/retention flag in atom line: #{atom_line}") unless (0..2).include? atom_line[63..65].to_i
      errors.add(:mol_file, "contains invalid exact change flag in atom line: #{atom_line}") unless (0..1).include? atom_line[66..68].to_i
    end

    # Verify structure of bond block.
    lines[bond_lines].each do |bond_line|
      errors.add(:mol_file, "contains invalid first atom number in bond line: #{bond_line}") unless (1..number_of_atoms).include? bond_line[0..2].to_i
      errors.add(:mol_file, "contains invalid second atom number in bond line: #{bond_line}") unless (1..number_of_atoms).include? bond_line[3..5].to_i
      errors.add(:mol_file, "contains invalid bond type in bond line: #{bond_line}") unless (1..8).include? bond_line[6..8].to_i
      errors.add(:mol_file, "contains invalid bond stereo in bond line: #{bond_line}") unless [0,1,3,4,6].include? bond_line[9..11].to_i
      # Positions 12 - 14 not used.
      errors.add(:mol_file, "contains invalid bond topology in bond line: #{bond_line}") unless (0..2).include? bond_line[15..17].to_i
      errors.add(:mol_file, "contains invalid reacting center status in bond line: #{bond_line}") unless [-1,0,1,4,5,9,8,12,13].include? bond_line[18..20].to_i
    end

    # Match any number of properties lines.
    lines[properties_lines].each do |line|
      errors.add(:mol_file, 'contains malformed properties block.') unless regex_properties.match(line)
    end

    # Match end of properties block.
    errors.add(:mol_file, 'contains unexpected end of properties block.') unless regex_properties_end.match(lines.last)
  end
end
